[Java高并发]Java内存模型中的原子性、有序性及可见性

[Java高并发]Java内存模型中的原子性、有序性及可见性

我们都知道Java内存模型都会运用主存,每个工作线程有自己的工作内存。数据在主存中会有一份,在工作内存中也有一份。工作内存和主存之间会有各种原子操作去进行同步。
此处输入图片的描述

内存模型的原型基于上图

原子性

原子性是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其它线程干扰。

一般认为cpu的指令都是原子操作,有些代码就不一定是原子操作了。
比如说i++。这个操作不是原子操作,基本分为3个操作,读取i,进行+1,赋值给i。

假设有两个线程,当第一个线程读取i=1时,还没进行+1操作,切换到第二个线程,此时第二个线程也读取的是i=1。随后两个线程进行后续+1操作,再赋值回去以后,i不是3,而是2。显然数据出现了不一致性。

再比如在32位的JVM上面去读取64位的long型数值,也不是一个原子操作。当然32位JVM读取32位整数是一个原子操作。

原子性的实现:Atomic类

就拿AtomicLong来说,它既解决了volatile的原子性没有保证的问题,又具有可见性。它是通过CAS(比较并交换)指令实现的。 其实AtomicLong的源码里也用到了volatile,但只是用来读取或写入。

有序性

  • 计算机在执行代码时,不一定会按照程序的顺序来执行。

那么为什么会发生乱序呢?这个要从cpu指令说起,Java中的代码被编译以后,最后也是转换成汇编码的。

一条指令的执行是可以分为很多步骤的,假设cpu指令分为以下几步

  • 取指 IF
  • 译码和取寄存器操作数 ID
  • 执行或者有效地址计算 EX
  • 存储器访问 MEM
  • 写回 WB

假设这里有两条指令 此处输入图片的描述

一般来说我们会认为指令是串行执行的,先执行指令1,然后再执行指令2。假设每个步骤需要消耗1个cpu时间周期,那么执行这两个指令需要消耗10个cpu时间周期,这样做效率太低。事实上指令都是并行执行的,当然在第一条指令在执行IF的时候,第二条指令是不能进行IF的,因为 指令寄存器等 不能被同时占用。所以就如上图所示,两条指令是一种相对错开的方式并行执行。当指令1执行ID的时候,指令2执行IF。这样只用6个cpu时间周期就执行了两个指令,效率比较高。

按照这个思路我们来看下A=B+C的指令是如何执行的。
此处输入图片的描述

这里注意,ADD操作时有一个空闲(X)操作,因为当想让B和C相加的时候,在图中ADD的X操作时,C还没从内存中读取(当MEM操作完成时,C才从内存中读取。这里会有一个疑问,此时还没有进行到R2的回写(WB),怎么会将R1与R2相加。那是因为在硬件电路当中,会使用一种叫“旁路”的技术直接把数据从硬件当中读取出来(不用放进去再取),所以不需要等待WB执行完才进行ADD)。所以ADD操作中会有一个空闲(X)时间。在SW操作中,因为EX指令不能和ADD的EX指令同时进行,所以也会有一个空闲(X)时间。

我们发现,这里的X很多,浪费的时间周期很多,性能也被影响。有没有办法使X的数量减少呢?

我们希望用一些操作把X的空闲时间填充掉,因为ADD与上面的指令有数据依赖,我们希望用一些没有数据依赖的指令去填充掉这些因为数据依赖而产生的空闲时间。

改变了指令顺序以后,X被消除了。总体的运行时间周期也减少了。

前提:当然指令重排的原则是不能破坏串行程序的语义,例如a=1,b=a+1,这种指令就不会重排了,因为重排的串行结果和原先的不同。

指令重排只是编译器或者CPU的优化一种方式,而这种优化就造成了程序间因不可见而的问题。

如何解决呢?用volatile关键字,这个后面会介绍到。

可见性

而volatile保证可见性的原理是什么呢?
volatile long vl = 0L;简而言之,当另外一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。它是轻量级的synchronized,不会引起线程上下文的切换和调度,执行开销更小。

使用Violatile修饰的变量在汇编阶段,会多出一条lock前缀指令,它在多核处理器下会引发两件事情:

  1. 将 当前处理器缓存行的数据 写回到系统内存。
  2. 这个写回内存的操作会使在其他 CPU里缓存了该内存地址的数据无效。

通常处理器和内存之间都有几级缓存来提高处理速度,处理器先将内存中的数据读取到内部缓存后再进行操作,但是对于缓存写会内存的时机则无法得知,因此在一个处理器里修改的变量值,不一定能及时写会缓存,这种变量修改对其他处理器变得“不可见”了。
但是,使用Volatile修饰的变量,在写操作的时候,会强制将这个变量所在缓存行的数据写回到内存中,但即使写回到内存,其他处理器也有可能使用内部的缓存数据,从而导致变量不一致,所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期,如果过期,就会将该缓存行设置成无效状态,下次要使用就会重新从内存中读取。

总结:通过清除缓存,强制cpu重新去内存读取保证可见性。

对于i++这样的非原子性操作。是分为三个操作来的。
可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对i值进行修改。然后虽然volatile能保证线程2对变量i的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。

  根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。因此在使用Violatile修饰变量时,一定要保证对该变量的写操作是原子性的,例如程序中的状态变量,对该变量的修改不依赖于其当前值。要解决此问题就要使用保证原子性的Atomic类,利用CAS机制来完成。

Powered by Hexo and Hexo-theme-hiker

Copyright © 2017 - 2019 Jae's blog All Rights Reserved.

UV : | PV :